今天接續昨天的內容,編寫FastHTML app,其版面預覽如下:
FastHTML是一個於2024年七月底八月初開始宣傳的全端Python框架,其核心概念是希望所有endpoint能夠直接返回HTML格式,而不是使用如XML或JSON的格式。此外,FastHTML整合了許多HTMX功能,使得其可以在幾乎不需撰寫JavaScript的情況下,與使用者有著良好的互動。如果搭配Alpine.js或是_hyperscript使用的話,更是可以呈現許多以往認為需要React或Vue等框架才能呈現的前端效果。
由於FastHTML仍在積極開發,文件說明大多也還在編寫中,所以很多功能需要由源碼自己推敲。而其源碼有兩個獨特的風格:
from xxx import *來導入模組。雖然一開始可能有點不習慣,可是在經過一段時間適應後,FastHTML帶來的開發效率的確令人驚豔。
建議大家可以先觀看官方的影片demo,對其有些基礎的了解後,再進入今天的內容(不然可能會覺得這個框架的用法太玄了?)。
utils.py提供兩個輔助函數:
get_todo_id()的功用為建立todo id來作為HTML的attribute。query2ft()的功用為將EdgeDB的query結果轉換為多個FastHTML component後回傳。如果是傳入一個內含單個或多個EdgeDB query結果的Iterable,則會將轉換後的FastHTML component包在列表中回傳。# app/utils.py
from dataclasses import asdict
def get_todo_id(id: str):
    return f"todo-{id}"
def query2ft(FTdataclass, query_results):
    def _query2ft(FTdataclass, query_result):
        return FTdataclass(**asdict(query_result))
    try:
        return [
            _query2ft(FTdataclass, query_result)
            for query_result in query_results
        ]
    except TypeError:  # not iterable
        return _query2ft(FTdataclass, query_results)
lifespan.py封裝所需資源作為fast_app的lifespan參數。
在_lifespan()中,我們使用SVCS提供的svcs.Registry.register_factory()將create_db_client()(回傳EdgeDB async client)註冊在factory內。
此外,這裡選擇使用_lifespan()與make_lifespan()來產生lifespan,而不使用裝飾器@svcs.starlette.lifespan()的原因,是覺得這麼寫比較容易測試。
# app/lifespan.py
import edgedb
import svcs
from edgedb.asyncio_client import AsyncIOClient
from starlette.applications import Starlette
async def _lifespan(app: Starlette, registry: svcs.Registry):
    db_client = edgedb.create_async_client()
    async def create_db_client():
        yield db_client
    registry.register_factory(AsyncIOClient, create_db_client)
    yield
    await registry.aclose()
def make_lifespan(_lifespan):
    return svcs.starlette.lifespan(_lifespan)
lifespan = make_lifespan(_lifespan)
Models.py定義Todo及TodoCreate兩個dataclass。
Todo中有一個__ft__(),這是FastHTML用來定義如何渲染FastHTML component的秘訣。其內使用了Strong、Li及A等HTML tag或FastHTML component來對每一個todo進行排版。
其中在A中,使用了三個HTMX參數:
hx_delete對應HTMX中的hx-delete,其功用是設定當A進行HTTP DELETE(註1)時所訪問的URL,即f"/{self.id}"。hx_target對應HTMX中的hx-target,其功用是指定A的回傳值所替換的對象,即f"#{get_todo_id(self.id)}"(Li)。hx_swap對應HTMX中的hx-swap,其功用是指定所需替換的部份,此處使用outerHTML來指定將會完全替換hx-target對象的所有HTML。可以理解為當A被點擊後,會將新的HTML結果完全取代id為get_todo_id(self.id)的tag(Li)。由於稍後我們會定義當使用HTTP DELETE訪問每一個f"/{self.id}"時,不回傳任何HTML tag或FastHTML component,也就是當A被點擊後,該todo將被刪除並替換為回傳值。但因為其沒有回傳值,所以看起來的效果就像是該todo消失於畫面中。
# app/models.py
from dataclasses import dataclass
from fasthtml.common import A, Li, Strong
from .utils import get_todo_id
@dataclass
class Todo:
    id: str
    title: str
    def __ft__(self):
        show = Strong(self.title, target_id="current-todo")
        delete = A(
            "delete",
            hx_delete=f"/{self.id}",
            hx_target=f"#{get_todo_id(self.id)}",
            hx_swap="outerHTML",
        )
        return Li(show, " | ", delete, id=get_todo_id(self.id))
@dataclass
class TodoCreate:
    title: str
app前置作業有三項工作:
app的fast_app instance及一個rt object。此處需使用我們於先前定義的lifespan及將svcs.starlette.SVCSMiddleware加入到middleware(這樣才可以使用SVCS)。mk_input(),其會回傳Input(對應HTML的input tag)作為使用者輸入todo的地方。# app/main.py
from dataclasses import asdict
import edgedb
import svcs
from edgedb.asyncio_client import AsyncIOClient
from fasthtml.common import (H1, Button, Card, Div, Form, Group, Input, Main,
                             Title, Ul, add_toast, fast_app, setup_toasts)
from starlette.middleware import Middleware
from starlette.requests import Request
from .lifespan import lifespan
from .models import Todo, TodoCreate
from .queries import create_todo_async_edgeql as create_todo_qry
from .queries import delete_todo_async_edgeql as delete_todo_qry
from .queries import get_todos_async_edgeql as get_todos_qry
from .utils import query2ft
app, rt = fast_app(
    lifespan=lifespan,
    middleware=[Middleware(svcs.starlette.SVCSMiddleware)],
)
setup_toasts(app)
def mk_input(**kw):
    return Input(id="new-title", name="title", placeholder="New Todo", **kw)
@rt("/")來定義post(),作為以HTTP POST訪問「"/"」時所使用的函數。post()的三個參數都可以被FastHTML獲取,因為:
session有設定setup_toasts(app)。request可經由Starlette自動捕抓。todo_create有TodoCreate作為型別提示來自動補抓。svcs.starlette.aget()獲取EdgeDB async client。create_todo_qry.create_todo()來執行query。
query2ft()將其轉換為FastHTML component回傳。此外,還必須同時回傳一個mk_input(),並將hx_swap_oob設為true。其目的為每次新增todo時,都會替換原先的Input,而不是添加一個新的Input。如果沒有將hx_swap_oob設為true的話,看起來的效果會像是每次建立todo後,原先輸入的內容卻仍然留在Input內。title property的constraint,此時我們使用add_toast()於畫面中跳出警告來提醒使用者。# app/main.py
@rt("/")
async def post(session, request: Request, todo_create: TodoCreate):
    try:
        db_client = await svcs.starlette.aget(request, AsyncIOClient)
        todo = await create_todo_qry.create_todo(
            db_client, **asdict(todo_create)
        )
        return query2ft(Todo, todo), mk_input(hx_swap_oob="true")
    except edgedb.errors.ConstraintViolationError:
        title = todo_create.title
        if len(title) < 1:
            err_msg = f'The title must contain at least 1 character.'
        elif len(title) > 50:
            err_msg = f'The title must not exceed 50 characters.'
        else:
            err_msg = f'The title "{title}" is duplicated.'
        add_toast(session, err_msg, "error")
@rt("/{tid}")來定義delete(),作為以HTTP DELETE訪問「"/{tid}"」時所使用的函數。delete()的兩個參數都可以由FastHTML獲取,因為:
request可經由Starlette自動捕抓。tid位於@rt("/{tid}")中。svcs.starlette.aget()獲取EdgeDB async client。delete_todo_qry.delete_todo()來執行query。由於我們並不需要對query結果做其它操作,所以不用定義變數接收,delete()函數也不必設定回傳值(Python會隱性回傳None)。
# app/main.py
@rt("/{tid}")
async def delete(request: Request, tid: str):
    db_client = await svcs.starlette.aget(request, AsyncIOClient)
    await delete_todo_qry.delete_todo(db_client, **{"id": tid})
@app.get("/")來定義homepage(),作為以HTTP GET訪問「"/"」時所使用的函數。homepage()的request參數可經由Starlette自動捕抓。svcs.starlette.aget()獲取EdgeDB async client。get_todos_qry.get_todos()來執行query。Form(對應HTML中的form tag):
mk_input()及一個Button(對應HTML中的botton tag)。hx_post對應HTMX中的hx-post,其功用是設定當form進行HTTP POST時所訪問的URL,即「"/"」。target_id對應HTMX中的hx-target,其功用是指定form的回傳值所替換的對象,即id為「"todo-list"」的tag(Card中的Ul)。hx_swap對應HTMX中的hx-swap,其功用是指定所需替換的部份,此處使用beforeend來指定將會將所取得的新HTML接在原有HTML之後,看起來的效果會像是將新todo加入至todo list的最底端。# app/main.py
@app.get("/")
async def homepage(request: Request):
    add = Form(
        Group(mk_input(), Button("Add")),
        hx_post="/",
        target_id="todo-list",
        hx_swap="beforeend",
    )
    db_client = await svcs.starlette.aget(request, AsyncIOClient)
    todos = await get_todos_qry.get_todos(db_client)
    card = (
        Card(
            Ul(*query2ft(Todo, todos), id="todo-list"),
            header=add,
            footer=Div(id="current-todo"),
        ),
    )
    return Title("Todo list built with SVCS, FastHTML, and EdgeDB."), Main(
        H1("Todo list"), card, cls="container"
    )
於命令列中執行下列指令:
uvicorn app.main:app --reload
接著打開瀏覽器前往預設網址,如http://127.0.0.1:8000/:
透過實際操作可以確認無論是新增todo、刪除todo及取得當前所有todo都可以正常執行。此外,當所輸入的todo已經存在EdgeDB中時,FastHTML的通知功能也能夠正常運作。
註1:在傳統的hypermedia系統中,唯二兩個互動元件為a tag(永遠發出HTTP GET)
及form tag(可以發出HTTP GET或HTTP POST)。如果想發出HTTP PUT、HTTP PATCH或HTTP DELETE,則需要搭配使用JavaScript與HTTP POST。有興趣了解的朋友,可以參考HTMX團隊所寫的Hypermedia Systems一書。
本日所有程式碼可參考fasthtml-svcs-edgedb-mvp repo。